{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|default_exp callback.experimental"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Experimental Callbacks"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    ">Miscellaneous experimental callbacks for timeseriesAI."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "import torch.multiprocessing\n",
    "torch.multiprocessing.set_sharing_strategy('file_system')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export \n",
    "from fastai.callback.all import *\n",
    "from tsai.imports import *\n",
    "from tsai.utils import *\n",
    "from tsai.data.preprocessing import *\n",
    "from tsai.data.transforms import *\n",
    "from tsai.models.layers import *\n",
    "from tsai.callback.MVP import *"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Gambler's loss: noisy labels"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "class GamblersCallback(Callback):\n",
    "    \"A callback to use metrics with gambler's loss\"\n",
    "    def after_loss(self): self.learn.pred = self.learn.pred[..., :-1]\n",
    "        \n",
    "\n",
    "def gambler_loss(reward=2):\n",
    "    def _gambler_loss(model_output, targets):\n",
    "        outputs = torch.nn.functional.softmax(model_output, dim=1)\n",
    "        outputs, reservation = outputs[:, :-1], outputs[:, -1]\n",
    "        gain = torch.gather(outputs, dim=1, index=targets.unsqueeze(1)).squeeze()\n",
    "        doubling_rate = (gain + reservation / reward).log()\n",
    "        return - doubling_rate.mean()\n",
    "    return "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from tsai.data.external import *\n",
    "from tsai.data.core import *\n",
    "from tsai.models.InceptionTime import *\n",
    "from tsai.models.layers import *\n",
    "from tsai.learner import *\n",
    "from fastai.metrics import *\n",
    "from tsai.metrics import *"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: left;\">\n",
       "      <th>epoch</th>\n",
       "      <th>train_loss</th>\n",
       "      <th>valid_loss</th>\n",
       "      <th>accuracy</th>\n",
       "      <th>time</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <td>0</td>\n",
       "      <td>1.840055</td>\n",
       "      <td>1.945397</td>\n",
       "      <td>0.166667</td>\n",
       "      <td>00:05</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>"
      ],
      "text/plain": [
       "<IPython.core.display.HTML object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "X, y, splits = get_UCR_data('NATOPS', return_split=False)\n",
    "tfms = [None, TSCategorize()]\n",
    "dsets = TSDatasets(X, y, tfms=tfms, splits=splits)\n",
    "dls = TSDataLoaders.from_dsets(dsets.train, dsets.valid, bs=[64, 128])\n",
    "loss_func = gambler_loss()\n",
    "learn = ts_learner(dls, InceptionTime(dls.vars, dls.c + 1), loss_func=loss_func, cbs=GamblersCallback, metrics=[accuracy])\n",
    "learn.fit_one_cycle(1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Uncertainty-based data augmentation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "class UBDAug(Callback):\n",
    "    r\"\"\"A callback to implement the uncertainty-based data augmentation.\"\"\"\n",
    "    \n",
    "    def __init__(self, batch_tfms:list, N:int=2, C:int=4, S:int=1): \n",
    "        r'''\n",
    "        Args:\n",
    "            batch_tfms:   list of available transforms applied to the combined batch. They will be applied in addition to the dl tfms.\n",
    "            N:            # composition steps (# transforms randomly applied to each sample)\n",
    "            C:            # augmented data per input data (# times N transforms are applied)\n",
    "            S:            # selected data points used for training (# augmented samples in the final batch from each original sample)\n",
    "        '''\n",
    "        \n",
    "        self.C, self.S = C, min(S, C)\n",
    "        self.batch_tfms = L(batch_tfms)\n",
    "        self.n_tfms = len(self.batch_tfms)\n",
    "        self.N = min(N, self.n_tfms)\n",
    "        \n",
    "    def before_fit(self):\n",
    "        assert hasattr(self.loss_func, 'reduction'), \"You need to pass a loss_function with a 'reduction' attribute\"\n",
    "        self.red = self.loss_func.reduction\n",
    "    \n",
    "    def before_batch(self):\n",
    "        if self.training:\n",
    "            with torch.no_grad():\n",
    "                setattr(self.loss_func, 'reduction', 'none')\n",
    "                for i in range(self.C):\n",
    "                    idxs = random_choice(self.n_tfms, self.N, False)\n",
    "                    x_tfm = compose_tfms(self.x, self.batch_tfms[idxs], split_idx=0)\n",
    "                    loss = self.loss_func(self.learn.model(x_tfm), self.y).reshape(-1,1)\n",
    "                    if i == 0:\n",
    "                        x2 = x_tfm.unsqueeze(1)\n",
    "                        max_loss = loss\n",
    "                    else: \n",
    "                        losses = torch.cat((max_loss, loss), dim=1)\n",
    "                        x2 = torch.cat((x2, x_tfm.unsqueeze(1)), dim=1)\n",
    "                        x2 = x2[np.arange(x2.shape[0]).reshape(-1,1), losses.argsort(1)[:, -self.S:]]\n",
    "                        max_loss = losses.max(1)[0].reshape(-1,1)\n",
    "                setattr(self.loss_func, 'reduction', self.red)\n",
    "            x2 = x2.reshape(-1, self.x.shape[-2], self.x.shape[-1])\n",
    "            if self.S > 1: self.learn.yb = (torch_tile(self.y, 2),)\n",
    "            self.learn.xb = (x2,)\n",
    "\n",
    "    def __repr__(self): return f'UBDAug({[get_tfm_name(t) for t in self.batch_tfms]})'"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from tsai.models.utils import *"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: left;\">\n",
       "      <th>epoch</th>\n",
       "      <th>train_loss</th>\n",
       "      <th>valid_loss</th>\n",
       "      <th>accuracy</th>\n",
       "      <th>time</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <td>0</td>\n",
       "      <td>1.817080</td>\n",
       "      <td>1.791119</td>\n",
       "      <td>0.077778</td>\n",
       "      <td>00:14</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>"
      ],
      "text/plain": [
       "<IPython.core.display.HTML object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "X, y, splits = get_UCR_data('NATOPS', return_split=False)\n",
    "tfms = [None, TSCategorize()]\n",
    "dsets = TSDatasets(X, y, tfms=tfms, splits=splits)\n",
    "dls = TSDataLoaders.from_dsets(dsets.train, dsets.valid, batch_tfms=[TSStandardize()])\n",
    "model = build_ts_model(InceptionTime, dls=dls)\n",
    "TS_tfms = [TSMagScale(.75, p=.5), TSMagWarp(.1, p=0.5),  TSWindowWarp(.25, p=.5), \n",
    "           TSSmooth(p=0.5), TSRandomResizedCrop(.1, p=.5), \n",
    "           TSRandomCropPad(.3, p=0.5), \n",
    "           TSMagAddNoise(.5, p=.5)]\n",
    "\n",
    "ubda_cb = UBDAug(TS_tfms, N=2, C=4, S=2)\n",
    "learn = ts_learner(dls, model, cbs=ubda_cb, metrics=accuracy)\n",
    "learn.fit_one_cycle(1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# BatchLossFilter"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "class BatchLossFilter(Callback):\n",
    "    \"\"\" Callback that selects the hardest samples in every batch representing a percentage of the total loss\"\"\"\n",
    "\n",
    "    def __init__(self, loss_perc=1., schedule_func:Optional[callable]=None):\n",
    "        store_attr()\n",
    "\n",
    "    def before_fit(self):\n",
    "        self.run = not hasattr(self, \"gather_preds\")\n",
    "        if not(self.run): return\n",
    "        self.crit = self.learn.loss_func\n",
    "        if hasattr(self.crit, 'reduction'): self.red = self.crit.reduction\n",
    "\n",
    "    def before_batch(self):\n",
    "        if not self.training: return\n",
    "        if self.schedule_func is None: loss_perc = self.loss_perc\n",
    "        else: loss_perc = self.loss_perc * self.schedule_func(self.pct_train)\n",
    "        if loss_perc == 1.: return\n",
    "        with torch.no_grad():\n",
    "            if hasattr(self.crit, 'reduction'):  setattr(self.crit, 'reduction', 'none')\n",
    "            losses = self.crit(self.learn.model(self.x), self.y)\n",
    "            if losses.ndim == 2: losses = losses.mean(-1)\n",
    "            if hasattr(self.crit, 'reduction'):  setattr(self.crit, 'reduction', self.red)\n",
    "            losses /= losses.sum()\n",
    "            idxs = torch.argsort(losses, descending=True)\n",
    "            cut_idx = max(1, torch.argmax((losses[idxs].cumsum(0) > loss_perc).float()))\n",
    "            idxs = idxs[:cut_idx]\n",
    "            self.learn.xb = tuple(xbi[idxs] for xbi in self.learn.xb)\n",
    "            self.learn.yb = tuple(ybi[idxs] for ybi in self.learn.yb)\n",
    "\n",
    "    def after_fit(self):\n",
    "        if hasattr(self.learn.loss_func, 'reduction'):  setattr(self.learn.loss_func, 'reduction', self.red)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# RandomWeightLossWrapper"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "\n",
    "class RandomWeightLossWrapper(Callback):\n",
    "\n",
    "    def before_fit(self):\n",
    "        self.run = not hasattr(self, \"gather_preds\")\n",
    "        if not(self.run): return\n",
    "        self.crit = self.learn.loss_func\n",
    "        if hasattr(self.crit, 'reduction'): self.red = self.crit.reduction\n",
    "        self.learn.loss_func = self._random_weight_loss\n",
    "\n",
    "    def _random_weight_loss(self, input: Tensor, target: Tensor) -> Tensor:\n",
    "        if self.training:\n",
    "            setattr(self.crit, 'reduction', 'none')\n",
    "            loss = self.crit(input, target)\n",
    "            setattr(self.crit, 'reduction', self.red)\n",
    "            rw = torch.rand(input.shape[0], device=input.device)\n",
    "            rw /= rw.sum()\n",
    "            non_red_loss = loss * rw\n",
    "            return non_red_loss.sum()\n",
    "        else:\n",
    "            return self.crit(input, target)\n",
    "\n",
    "    def after_fit(self):\n",
    "        if hasattr(self.crit, 'reduction'): setattr(self.crit, 'reduction', self.red)\n",
    "        self.learn.loss_func = self.crit"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# BatchMasker"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "\n",
    "class BatchMasker(Callback):\n",
    "    \"\"\" Callback that applies a random mask to each sample in a training batch\n",
    "\n",
    "    Args:\n",
    "    ====\n",
    "    r:                  probability of masking.\n",
    "    subsequence_mask:   apply a mask to random subsequences.\n",
    "    lm:                 average mask len when using stateful (geometric) masking.\n",
    "    stateful:           geometric distribution is applied so that average mask length is lm.\n",
    "    sync:               all variables have the same masking.\n",
    "    variable_mask:      apply a mask to random variables. Only applicable to multivariate time series.\n",
    "    future_mask:        used to train a forecasting model.\n",
    "    schedule_func:      if a scheduler is passed, it will modify the probability of masking during training.\n",
    "    \"\"\"\n",
    "\n",
    "    def __init__(self, r:float=.15, lm:int=3, stateful:bool=True, sync:bool=False, subsequence_mask:bool=True, \n",
    "                 variable_mask:bool=False, future_mask:bool=False, schedule_func:Optional[callable]=None):\n",
    "        store_attr()\n",
    "\n",
    "    def before_fit(self):\n",
    "        self.run = not hasattr(self, \"gather_preds\")\n",
    "        if not(self.run): return\n",
    "\n",
    "    def before_batch(self):\n",
    "        if not self.training: return\n",
    "        r = self.r * self.schedule_func(self.pct_train) if self.schedule_func is not None else self.r\n",
    "        mask = create_mask(self.x,  r=r, lm=self.lm, stateful=self.stateful, sync=self.sync, \n",
    "                        subsequence_mask=self.subsequence_mask, variable_mask=self.variable_mask, future_mask=self.future_mask)\n",
    "        self.learn.xb = (self.xb[0].masked_fill(mask, 0),)\n",
    "        # In my tests, mask-based compensation doesn't seem to be important. ??\n",
    "        # mean_per_seq = (torch.max(torch.ones(1, device=mask.device), torch.sum(mask, dim=-1).unsqueeze(-1)) / mask.shape[-1])\n",
    "        # self.learn.xb = (self.xb[0].masked_fill(mask, 0) / (1 - mean_per_seq), )"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# SamplerWithReplacement"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "\n",
    "class SamplerWithReplacement(Callback):\n",
    "    \"\"\" Callback that modify the sampler to select a percentage of samples and/ or sequence steps with replacement from each training batch\"\"\"\n",
    "\n",
    "    def before_fit(self):\n",
    "        self.run = not hasattr(self, \"gather_preds\")\n",
    "        if not(self.run): return\n",
    "\n",
    "        self.old_get_idxs = self.learn.dls.train.get_idxs\n",
    "        self.learn.dls.train.get_idxs = self._get_idxs\n",
    "\n",
    "    def _get_idxs(self):\n",
    "        dl = self.learn.dls.train\n",
    "        if dl.n==0: return []\n",
    "        if dl.weights is not None:\n",
    "            return random_choice(dl.n, dl.n, p=dl.weights)\n",
    "        idxs = Inf.count if dl.indexed else Inf.nones\n",
    "        if dl.n is not None: idxs = random_choice(dl.n,dl.n,True)\n",
    "        if dl.shuffle: idxs = dl.shuffle_fn(idxs)\n",
    "        return idxs\n",
    "\n",
    "    def after_fit(self):\n",
    "        self.learn.dls.train.get_idxs = self.old_get_idxs"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/javascript": "IPython.notebook.save_checkpoint();",
      "text/plain": [
       "<IPython.core.display.Javascript object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "/Users/nacho/notebooks/tsai/nbs/060_callback.experimental.ipynb saved at 2022-11-09 12:56:03\n",
      "Correct notebook to script conversion! 😃\n",
      "Wednesday 09/11/22 12:56:06 CET\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "\n",
       "                <audio  controls=\"controls\" autoplay=\"autoplay\">\n",
       "                    <source src=\"data:audio/wav;base64,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\" type=\"audio/wav\" />\n",
       "                    Your browser does not support the audio element.\n",
       "                </audio>\n",
       "              "
      ],
      "text/plain": [
       "<IPython.lib.display.Audio object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "#|eval: false\n",
    "#|hide\n",
    "from tsai.export import get_nb_name; nb_name = get_nb_name(locals())\n",
    "from tsai.imports import create_scripts; create_scripts(nb_name)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "python3",
   "language": "python",
   "name": "python3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
